route.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455
  1. import bcrypt from "bcryptjs";
  2. import User, { USER_ROLES } from "@/models/user";
  3. import { getDb } from "@/lib/db";
  4. import { getSession } from "@/lib/auth/session";
  5. import { requireUserManagement } from "@/lib/auth/permissions";
  6. import { generateAdminTemporaryPassword } from "@/lib/auth/adminTempPassword";
  7. import {
  8. withErrorHandling,
  9. json,
  10. badRequest,
  11. unauthorized,
  12. notFound,
  13. } from "@/lib/api/errors";
  14. export const dynamic = "force-dynamic";
  15. const BRANCH_RE = /^NL\d+$/;
  16. const OBJECT_ID_RE = /^[a-f0-9]{24}$/i;
  17. const USERNAME_RE = /^[a-z0-9][a-z0-9._-]{2,31}$/; // 3..32, conservative
  18. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  19. const BCRYPT_SALT_ROUNDS = 12;
  20. const ALLOWED_ROLES = new Set(Object.values(USER_ROLES));
  21. const ALLOWED_UPDATE_FIELDS = Object.freeze([
  22. "username",
  23. "email",
  24. "role",
  25. "branchId",
  26. "mustChangePassword",
  27. ]);
  28. function isPlainObject(value) {
  29. return Boolean(value && typeof value === "object" && !Array.isArray(value));
  30. }
  31. function isNonEmptyString(value) {
  32. return typeof value === "string" && value.trim().length > 0;
  33. }
  34. function normalizeUsername(value) {
  35. return String(value || "")
  36. .trim()
  37. .toLowerCase();
  38. }
  39. function normalizeEmail(value) {
  40. return String(value || "")
  41. .trim()
  42. .toLowerCase();
  43. }
  44. function normalizeBranchId(value) {
  45. return String(value || "")
  46. .trim()
  47. .toUpperCase();
  48. }
  49. function toIsoOrNull(value) {
  50. if (!value) return null;
  51. try {
  52. return new Date(value).toISOString();
  53. } catch {
  54. return null;
  55. }
  56. }
  57. function toSafeUser(doc) {
  58. return {
  59. id: String(doc?._id),
  60. username: typeof doc?.username === "string" ? doc.username : "",
  61. email: typeof doc?.email === "string" ? doc.email : "",
  62. role: typeof doc?.role === "string" ? doc.role : "",
  63. branchId: doc?.branchId ?? null,
  64. mustChangePassword: Boolean(doc?.mustChangePassword),
  65. createdAt: toIsoOrNull(doc?.createdAt),
  66. updatedAt: toIsoOrNull(doc?.updatedAt),
  67. };
  68. }
  69. function pickDuplicateField(err) {
  70. if (!err || typeof err !== "object") return null;
  71. const keyValue =
  72. err.keyValue && typeof err.keyValue === "object" ? err.keyValue : null;
  73. if (keyValue) {
  74. const keys = Object.keys(keyValue);
  75. if (keys.length > 0) return keys[0];
  76. }
  77. const keyPattern =
  78. err.keyPattern && typeof err.keyPattern === "object"
  79. ? err.keyPattern
  80. : null;
  81. if (keyPattern) {
  82. const keys = Object.keys(keyPattern);
  83. if (keys.length > 0) return keys[0];
  84. }
  85. return null;
  86. }
  87. async function resolveValidatedUserId(ctx) {
  88. const { userId } = await ctx.params;
  89. if (!userId) {
  90. throw badRequest(
  91. "VALIDATION_MISSING_PARAM",
  92. "Missing required route parameter(s)",
  93. { params: ["userId"] },
  94. );
  95. }
  96. if (!OBJECT_ID_RE.test(String(userId))) {
  97. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid userId", {
  98. field: "userId",
  99. value: userId,
  100. });
  101. }
  102. return String(userId);
  103. }
  104. export const PATCH = withErrorHandling(
  105. async function PATCH(request, ctx) {
  106. const session = await getSession();
  107. if (!session) {
  108. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  109. }
  110. requireUserManagement(session);
  111. const userId = await resolveValidatedUserId(ctx);
  112. let body;
  113. try {
  114. body = await request.json();
  115. } catch {
  116. throw badRequest("VALIDATION_INVALID_JSON", "Invalid request body");
  117. }
  118. if (!isPlainObject(body)) {
  119. throw badRequest("VALIDATION_INVALID_BODY", "Invalid request body");
  120. }
  121. const hasUpdateField = Object.keys(body).some((k) =>
  122. ALLOWED_UPDATE_FIELDS.includes(k),
  123. );
  124. if (!hasUpdateField) {
  125. throw badRequest("VALIDATION_MISSING_FIELD", "Missing fields to update", {
  126. fields: [...ALLOWED_UPDATE_FIELDS],
  127. });
  128. }
  129. await getDb();
  130. const user = await User.findById(String(userId)).exec();
  131. if (!user) {
  132. throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
  133. }
  134. const patch = {};
  135. // username (optional)
  136. if (Object.prototype.hasOwnProperty.call(body, "username")) {
  137. if (!isNonEmptyString(body.username)) {
  138. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  139. field: "username",
  140. value: body.username,
  141. });
  142. }
  143. const username = normalizeUsername(body.username);
  144. if (!USERNAME_RE.test(username)) {
  145. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid username", {
  146. field: "username",
  147. value: username,
  148. pattern: String(USERNAME_RE),
  149. });
  150. }
  151. const existing = await User.findOne({
  152. username,
  153. _id: { $ne: String(userId) },
  154. })
  155. .select("_id")
  156. .exec();
  157. if (existing) {
  158. throw badRequest(
  159. "VALIDATION_INVALID_FIELD",
  160. "Username already exists",
  161. {
  162. field: "username",
  163. value: username,
  164. },
  165. );
  166. }
  167. patch.username = username;
  168. }
  169. // email (optional)
  170. if (Object.prototype.hasOwnProperty.call(body, "email")) {
  171. if (!isNonEmptyString(body.email)) {
  172. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  173. field: "email",
  174. value: body.email,
  175. });
  176. }
  177. const email = normalizeEmail(body.email);
  178. if (!EMAIL_RE.test(email)) {
  179. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid email", {
  180. field: "email",
  181. value: email,
  182. });
  183. }
  184. const existing = await User.findOne({
  185. email,
  186. _id: { $ne: String(userId) },
  187. })
  188. .select("_id")
  189. .exec();
  190. if (existing) {
  191. throw badRequest("VALIDATION_INVALID_FIELD", "Email already exists", {
  192. field: "email",
  193. value: email,
  194. });
  195. }
  196. patch.email = email;
  197. }
  198. // role (optional)
  199. if (Object.prototype.hasOwnProperty.call(body, "role")) {
  200. if (!isNonEmptyString(body.role)) {
  201. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  202. field: "role",
  203. value: body.role,
  204. allowed: Array.from(ALLOWED_ROLES),
  205. });
  206. }
  207. const role = String(body.role).trim();
  208. if (!ALLOWED_ROLES.has(role)) {
  209. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid role", {
  210. field: "role",
  211. value: role,
  212. allowed: Array.from(ALLOWED_ROLES),
  213. });
  214. }
  215. patch.role = role;
  216. }
  217. // branchId (optional, can be null)
  218. if (Object.prototype.hasOwnProperty.call(body, "branchId")) {
  219. if (body.branchId === null) {
  220. patch.branchId = null;
  221. } else if (isNonEmptyString(body.branchId)) {
  222. patch.branchId = normalizeBranchId(body.branchId);
  223. } else {
  224. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  225. field: "branchId",
  226. value: body.branchId,
  227. pattern: "^NL\\d+$",
  228. });
  229. }
  230. }
  231. // mustChangePassword (optional)
  232. if (Object.prototype.hasOwnProperty.call(body, "mustChangePassword")) {
  233. if (typeof body.mustChangePassword !== "boolean") {
  234. throw badRequest(
  235. "VALIDATION_INVALID_FIELD",
  236. "Invalid mustChangePassword",
  237. {
  238. field: "mustChangePassword",
  239. value: body.mustChangePassword,
  240. },
  241. );
  242. }
  243. patch.mustChangePassword = body.mustChangePassword;
  244. }
  245. // --- Enforce role <-> branchId consistency --------------------------------
  246. const nextRole = patch.role ?? user.role;
  247. const nextBranchId = Object.prototype.hasOwnProperty.call(patch, "branchId")
  248. ? patch.branchId
  249. : (user.branchId ?? null);
  250. if (nextRole === USER_ROLES.BRANCH) {
  251. if (!isNonEmptyString(nextBranchId)) {
  252. throw badRequest(
  253. "VALIDATION_MISSING_FIELD",
  254. "Missing required fields",
  255. {
  256. fields: ["branchId"],
  257. },
  258. );
  259. }
  260. const normalized = normalizeBranchId(nextBranchId);
  261. if (!BRANCH_RE.test(normalized)) {
  262. throw badRequest("VALIDATION_INVALID_FIELD", "Invalid branchId", {
  263. field: "branchId",
  264. value: normalized,
  265. pattern: "^NL\\d+$",
  266. });
  267. }
  268. patch.branchId = normalized;
  269. } else {
  270. // For non-branch users, always clear branchId
  271. patch.branchId = null;
  272. }
  273. // --- Apply patch ----------------------------------------------------------
  274. if (Object.prototype.hasOwnProperty.call(patch, "username"))
  275. user.username = patch.username;
  276. if (Object.prototype.hasOwnProperty.call(patch, "email"))
  277. user.email = patch.email;
  278. if (Object.prototype.hasOwnProperty.call(patch, "role"))
  279. user.role = patch.role;
  280. if (Object.prototype.hasOwnProperty.call(patch, "branchId"))
  281. user.branchId = patch.branchId;
  282. if (Object.prototype.hasOwnProperty.call(patch, "mustChangePassword"))
  283. user.mustChangePassword = patch.mustChangePassword;
  284. try {
  285. await user.save();
  286. } catch (err) {
  287. if (err && err.code === 11000) {
  288. const field = pickDuplicateField(err) || "unknown";
  289. throw badRequest("VALIDATION_INVALID_FIELD", "Duplicate key", {
  290. field,
  291. });
  292. }
  293. throw err;
  294. }
  295. return json({ ok: true, user: toSafeUser(user) }, 200);
  296. },
  297. { logPrefix: "[api/admin/users/[userId]]" },
  298. );
  299. export const POST = withErrorHandling(
  300. async function POST(_request, ctx) {
  301. const session = await getSession();
  302. if (!session) {
  303. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  304. }
  305. requireUserManagement(session);
  306. const userId = await resolveValidatedUserId(ctx);
  307. if (String(session.userId) === userId) {
  308. throw badRequest(
  309. "VALIDATION_INVALID_FIELD",
  310. "Cannot reset current user password",
  311. {
  312. field: "userId",
  313. reason: "SELF_PASSWORD_RESET_FORBIDDEN",
  314. },
  315. );
  316. }
  317. await getDb();
  318. const user = await User.findById(userId).exec();
  319. if (!user) {
  320. throw notFound("USER_NOT_FOUND", "Not found", { userId });
  321. }
  322. const temporaryPassword = generateAdminTemporaryPassword();
  323. const passwordHash = await bcrypt.hash(
  324. temporaryPassword,
  325. BCRYPT_SALT_ROUNDS,
  326. );
  327. user.passwordHash = passwordHash;
  328. user.mustChangePassword = true;
  329. user.passwordResetToken = null;
  330. user.passwordResetExpiresAt = null;
  331. await user.save();
  332. return json(
  333. {
  334. ok: true,
  335. user: toSafeUser(user),
  336. temporaryPassword,
  337. },
  338. 200,
  339. );
  340. },
  341. { logPrefix: "[api/admin/users/[userId]]" },
  342. );
  343. export const DELETE = withErrorHandling(
  344. async function DELETE(request, ctx) {
  345. const session = await getSession();
  346. if (!session) {
  347. throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized");
  348. }
  349. requireUserManagement(session);
  350. const userId = await resolveValidatedUserId(ctx);
  351. // Safety: prevent deleting your own currently active account.
  352. if (String(session.userId) === userId) {
  353. throw badRequest(
  354. "VALIDATION_INVALID_FIELD",
  355. "Cannot delete current user",
  356. {
  357. field: "userId",
  358. reason: "SELF_DELETE_FORBIDDEN",
  359. },
  360. );
  361. }
  362. await getDb();
  363. const deleted = await User.findByIdAndDelete(String(userId))
  364. .select(
  365. "_id username email role branchId mustChangePassword createdAt updatedAt",
  366. )
  367. .exec();
  368. if (!deleted) {
  369. throw notFound("USER_NOT_FOUND", "Not found", { userId: String(userId) });
  370. }
  371. return json({ ok: true, user: toSafeUser(deleted) }, 200);
  372. },
  373. { logPrefix: "[api/admin/users/[userId]]" },
  374. );